perf(www): cache index.html at edge, lazy-load auth dialogs#141
Merged
Conversation
Cold Lighthouse audit on goosebumps.fm showed LCP 1.9s despite prior bundle-size work (#139). Two contributors found: 1. index.html served with no-store, so Cloudflare/CloudFront never cache it at edge. Every cold visit pays a full origin round-trip (measured 829ms-1.3s TTFB) before the browser has HTML to parse. Give HTML a short edge TTL instead. 2. Root route eagerly imported AuthPromptDialog and WelcomeModal, both closed-by-default dialogs, forcing their chunks (and transitively use-toast/lucide icon code) into the 23 modulepreload tags fetched on every route load including home. Lazy-load them like QueueColumn already is in AppShell.tsx.
…orkers DNS already lives on Cloudflare (proxy: true), so S3+CloudFront behind the Cloudflare edge was a redundant double-CDN hop, adding latency and a second cache layer to reason about (directly complicating the prior commit's index.html edge-cache change). Swap sst.aws.StaticSite for sst.cloudflare.StaticSiteV2 (Workers + static assets, SST's current recommended pattern per examples/cloudflare-vite). Also drops CloudFront/ACM/S3 provisioning and AWS credentials from the www deploy path. StaticSiteV2 has no assets.fileOptions API — Workers Static Assets defaults every file to 'public, max-age=0, must-revalidate'. Replaced with a Cloudflare-native apps/www/public/_headers file, copied verbatim into dist/ by Vite, to keep the same immutable long-cache for hashed assets/fonts and 60s revalidate for HTML.
|
🎉 This PR is included in version 2.65.2 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
guidefari
added a commit
that referenced
this pull request
Jul 10, 2026
Follows the existing /changelog pattern: a real markdown doc (docs/PERF_LOG.md) compiled to MDX at build time via a Vite virtual module, so the page can't drift from what's actually documented in the repo. Covers the full arc: bundle-size cut (#139), edge-caching + lazy dialogs + AWS-to-Cloudflare migration (#141), and the root cache-control follow-up fix, with before/after numbers measured against production.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #139. A cold Lighthouse run against production (desktop, no cache) showed LCP at 1.9s, worse than the 1.0s that PR claimed post-fix — so there was still room. This PR addresses the two biggest levers found.
What changed
1.
infra/www.ts— edge-cacheindex.htmlBefore:
index.htmlwas served withCache-Control: max-age=0,no-cache,no-store,must-revalidate— SST'saws.StaticSitedefault for HTML files (docs).Problem: Cloudflare is proxying in front of CloudFront (
infra/www.ts—dns: sst.cloudflare.dns({ proxy: true })). Both layers respect standardCache-Controlsemantics (MDN: Cache-Control), andno-storetells them never to cache the document at edge. Every cold visit — first-time visitor, or anyone hitting a CloudFront PoP after cache expiry — pays a full origin round-trip just to get the HTML shell, before the browser can even start parsing.Measured directly against prod:
Lighthouse's
server-response-timeaudit independently flagged 829ms on the root document with ~729ms of possible savings.Static hashed assets (
/assets/*.js,.css) were already correct —public, max-age=31536000, immutable— no change needed there.Fix: override
assets.fileOptionsto give HTML a 60s edge TTL instead ofno-store:This is the documented override point for
sst.aws.StaticSite(StaticSiteArgs.assets); order matters because laterfileOptionsentries win over earlier globs — see sst#5673 for a real-world case of this glob-ordering silently breaking someone's intended cache config, which is why the catch-all is listed first and the HTML override second, matching SST's own documented default ordering.Trade-off: deploys become visible at each edge PoP within ~60s instead of instantly. For a solo project's release cadence this is negligible against the TTFB win.
2.
apps/www/src/routes/__root.tsx— lazy-load closed-by-default dialogsThe deployed homepage's
<head>carries 23<link rel="modulepreload">tags — the browser fetches, parses, and compiles all 23 chunks at high priority on every route load, home included. Lighthouse'sunused-javascriptaudit showed why that's wasteful:index-*.jsesm-*.jsruntime-*.js__root.tsxstatically importedAuthPromptDialogandWelcomeModal— both are dialogs that rendernull/closed by default (AuthPromptDialogopens only via a Zustand store flag;WelcomeModalopens only for authenticated users who haven't seen it, 500ms after mount). Because TanStack Router'sautoCodeSplittingtreats anything statically reachable from the root route as always-needed, both components' code (plus transitivelyuse-toast,lucide-reacticon chunks, and part of the better-auth client) were forced into the eager preload graph rather than route-level chunks.AppShell.tsxalready established the right pattern for this exact situation —QueueColumnis lazy-loaded there:This PR applies the same pattern to the two root-level dialogs:
wrapped in
<Suspense fallback={null}>around both.Why
Suspensehere specifically, and why it's safe:React.lazy()returns a component whose module import is deferred until first render — but React needs to suspend rendering while that import promise resolves, andSuspenseis what catches that and shows a fallback in the meantime. Without wrapping<WelcomeModal />/<AuthPromptDialog />in<Suspense>, React throws because there's nothing to catch the pending promise on first mount.fallback={null}is correct (not just convenient) because both components render nothing visible until their own internalopen/isOpenstate flips true — there is no meaningful loading state to show, sonullcosts nothing visually. Net effect: their code (and the toast/icon/auth-client chunks only they pulled in) moves out of the initial modulepreload set and into an on-demand chunk fetched only when a user actually opens sign-in or gets the welcome dialog.Confirmed via build:
AuthPromptDialognow emits as its own chunk (AuthPromptDialog-*.js, 4.88 kB) instead of being folded intoindex-*.js.Known remaining limit: the modulepreload count didn't drop to zero-bloat —
FloatingMenu(rendered on every route viaAppShell) legitimately callsuseSession()for nav state, which is real product logic, not incidental bloat, and it keeps pulling in the better-auth client chunks (bundle-mjs,middleware,useMutation) eagerly. Shrinking that further would mean swapping better-auth's client for a lighter session-check-only path — a bigger, separate effort, out of scope here.3.
infra/www.ts— migrate static site from AWS (S3+CloudFront) to Cloudflare WorkersDNS for
goosebumps.fmalready lives on Cloudflare with the proxy on (seeinfra/www.ts's olddns: sst.cloudflare.dns({ proxy: true })), so S3+CloudFront sat behind the Cloudflare edge — a redundant second CDN hop, and a second place cache headers had to be reasoned about (directly what made change #1 above more complicated than it needed to be).Before:
sst.aws.StaticSite— provisions S3 bucket + CloudFront distribution + ACM cert, cache headers set viaassets.fileOptions.After:
sst.cloudflare.StaticSiteV2— provisions a Cloudflare Worker with a native static-assets binding, no AWS resources, no second CDN hop. This is SST's own current recommended pattern for a Vite SPA on Cloudflare, not something improvised — matches their reference example exactly:static-site-v2.tsexamples/cloudflare-vite/sst.config.tssst.cloudflare.StaticSite(KV-backed) is explicitly deprecated in favor of V2 — see the@deprecatedtag onstatic-site.ts.Links point at tag
v4.15.2, matching thesstversion pinned in this repo'spackage.json.Cache config had to move, not just get deleted.
StaticSiteV2has noassets.fileOptionsequivalent — the router worker (cf-static-site-router-worker-experimental) just delegates straight toenv.ASSETS.fetch(), Cloudflare's native Workers Static Assets binding, which defaults every file — including hashed JS/CSS — topublic, max-age=0, must-revalidate(Cloudflare docs). Left alone, that's a regression versus what we had.Fix: added
apps/www/public/_headers, Cloudflare's own convention for overriding this (same doc link above). Vite copies everything inpublic/intodist/untouched, so it ships as-is:Same effective policy as the old
fileOptionsblock — just expressed in the new platform's native mechanism instead of SST's.Impact of the 1yr immutable cache on
/assets/*, and how it gets busted: this is safe specifically because Vite content-hashes every built filename — e.g.index-CSxTSgJW.js, whereCSxTSgJWis a hash of the file's contents. A code change produces a new filename, never a mutated old one.index.html(60s cache, not 1yr) is what references those hashed filenames via<script src="/assets/index-CSxTSgJW.js">. So the actual invalidation path on deploy is:index.html(and any other.html) referencing the new hashes goes live at edge within 60s (the HTML's own cache TTL).The only thing that would break this is emitting HTML without re-hashing referenced assets (not something Vite does) or serving stale
index.htmlpast its 60s TTL — which is exactly why HTML gets the short TTL and assets get the long one, not the reverse.Test plan
bun run typecheckclean inapps/wwwbun run build— confirmedAuthPromptDialogsplit into its own chunkbun precommitclean after the Cloudflare migration (typecheck across all workspaces)www.goosebumps.fm/goosebumps.fmcompletes cleanly with no DNS/routing gaphttps://claude.ai/code/session_01N27hN1p1WPHzzHwbi7nrXX